qdanshitaのチューニング紹介
概要
nginx-luajit-websocket-udp、略してqdanshitaになった。
各機構の設定の関連性や、ログ、チューニングについて書く。
あとベンチマーク用にlocustスクリプトを用意したので、それについても書く。
リポジトリはここ。
https://github.com/sassembla/nginx-luajit-ws/tree/benchmark-with-netcore
機構構成
nginx + lua(micro websocket server per connection)、
nginx stream + go-udp-server(udp receiver/sender)
disque(upstream/downstream message queue) を組み合わせた機構。
特性
・1ユーザーにつき、消費するポートはユーザ接続tcp1つ + disque-nginx up/down tcp2 の3つ。
・queueを挟むため、負荷に強い。
・upstream(上流サーバ)を完全非同期に実装できる。この仕組みは、ゲームロジックをbackpressureの負荷を考えずに実行するのに役立つ。
接続とデータフロー
0.マッチング、接続に必要なパラメータを取得
1.クライアントとの間にudpでのデータ送付経路を確立
2. 0,1で取得したパラメータを使って、websocketでの接続を行う。
udp接続の手順はスキップすることができ、その場合downstreamでのudp送付は発生しなくなる。(要luaカスタマイズ)
接続後、udp、wsを介してdownstreamのデータがサーバからクライアントへと送付される。
nginx.conf、lua、go-udp-server、disqueのセッティング
nginx.conf
nginx.conf(https://github.com/sassembla/nginx-luajit-ws/blob/benchmark-with-netcore/DockerResources/nginx.conf)
workerごとの接続数などを設定する。
nginx streamで8080ポートで受け止めたudpを8081ポートで待つgo-udp-serverへとproxyしている。
urlに対してluaファイルのパスを指定、ルーティングを行う。
サンプルでは、次のような記述で、http://SOMEWHERE/sample_disque_client へと到達したリクエストを、sample_disque_client.luaスクリプトへと転送する。
# sample disque client route.
location /sample_disque_client {
content_by_lua_file lua/sample_disque_client.lua;
}
lua
/luaフォルダ以下に入っている。
nginx.confのルーティングから呼ばれ、リクエスト時に実行される。
セッティング、リクエストヘッダ値の読み出し/分岐、そのパラメータを使った認証機構を入れる箇所がある。
また、upstreamが リクエストurl末尾path + _context というqueue名でdisqueからデータを引き出せるようになっている。
sample_disque_client.lua(https://github.com/sassembla/nginx-luajit-ws/blob/benchmark-with-netcore/DockerResources/lua/sample_disque_client.lua)
-- get identity of game from url. e.g. http://somewhere/game_key -> game_key_context.
UPSTREAM_IDENTIFIER = string.gsub(ngx.var.uri, "/", "") .. "_context"
-- message type definitions.
STATE_CONNECT = 1
STATE_STRING_MESSAGE = 2
STATE_BINARY_MESSAGE = 3
STATE_DISCONNECT_INTENT = 4
STATE_DISCONNECT_ACCIDT = 5
STATE_DISCONNECT_DISQUE_ACKFAILED = 6
STATE_DISCONNECT_DISQUE_ACCIDT_SENDFAILED = 7
---- SETTINGS ----
-- upstream/downstream queue.
DISQUE_IP = "127.0.0.1"
DISQUE_PORT = 7711
-- CONNECTION_ID is nginx's request id. that len is 32. guidv4 length is 36, add four "0".
-- overwritten by token.
CONNECTION_ID = ngx.var.request_id .. "0000"
-- go unix domain socket path.
UNIX_DOMAIN_SOCKET_PATH = "unix:/tmp/go-udp-server"
-- max size of downstream message.
DOWNSTREAM_MAX_PAYLOAD_LEN = 1024
---- REQUEST HEADER PARAMS ----
local token = ngx.req.get_headers()["token"]
if not token then
ngx.log(ngx.ERR, "no token.")
return
end
local udp_port = ngx.req.get_headers()["param"]
if not udp_port then
ngx.log(ngx.ERR, "no param.")
return
end
---- POINT BEFORE CONNECT ----
-- redis example.
-- このままだと通信単位でredisアクセスが発生しちゃうので、このブロック内で、なんらかのtokenチェックをやるとかするとなお良い。このサーバにくるはずなら~とかそういう要素で。
if false then
local redis = require "redis.redis"
local redisConn = redis:new()
local ok, err = redisConn:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "connection:", CONNECTION_ID, " failed to generate redis client. err:", err)
return
end
-- トークンをキーにして取得
local res, err = redisConn:get(token)
-- キーがkvsになかったら認証失敗として終了
if not res then
-- no key found.
ngx.log(ngx.ERR, "connection:", CONNECTION_ID, " failed to authenticate. no token found in kvs.")
-- 切断
redisConn:close()
ngx.exit(200)
return
elseif res == ngx.null then
-- no value found.
ngx.log(ngx.ERR, "connection:", CONNECTION_ID, " failed to authenticate. token is nil.")
-- 切断
redisConn:close()
ngx.exit(200)
return
end
-- delete got key.
local ok, err = redisConn:del(token)
-- 切断
redisConn:close()
-- 変数にセット、パラメータとして渡す。
user_data = res
else
user_data = token
CONNECTION_ID = token
end
-- ngx.log(ngx.ERR, "connection:", CONNECTION_ID, " user_data:", user_data)
---- CONNECT ----
...
go-udp-server
/goフォルダ以下に入っている。
main.go(https://github.com/sassembla/nginx-luajit-ws/blob/benchmark-with-netcore/DockerResources/go/main.go)
をコンパイルして起動しておく。
デフォルトでは8081ポートでudp接続を受け付け、tmp/go-udp-serverという名前のunix domain socketを読み込む。
nginx stream機構の下で動くのを基礎としていて、nginxは8080/udpでudpを受け付け、8081 go-udp-serverへとデータをproxyする。
起動時にオプションを渡すことで、デフォルト設定を上書きすることができる。centosなどを使う際は適当に指定するといいと思う。
--portオプションでudpを待ち受けるポートを指定、
--domainオプションで、unix domain socketのパスを指定する。
負荷が高くなるとlua側で shared connection is busy while proxying connection ログが出る。ただ、これが出る状態がすでにコア数に対してnginxのworkerがサチっている
disque
disque-serverを別途起動しておく。
起動時に --maxclients 100000 とかつけておくと、disqueのデフォルト値の10000以上の接続が可能になる。
luaから接続 -> disque -> upstreamへとデータを送り、
upstream -> disque -> lua -> クライアントへとデータを送る中継点に使われる。
Upstream
要はServer。
サンプルとして、約60fpsでクライアントへとechoを行うdotnet coreの機構を用意してある。
https://github.com/sassembla/nginx-luajit-ws/tree/benchmark-with-netcore/DockerResources/csharp
ログ
接続、切断、エラーなどの基本的な情報は、すべてnginxのerror.logに出るようになっている。
デフォルトでは切断時、エラー時のみログを出力している。
出力もとはluaなので、編集したい場合はそちらを。
チューニング
nginx error log
-> max number of clients reached
-> disqueのmax connection設定を上回る接続がdisqueに来た。 デフォルトは10000、disqueを--maxclients 100000とかつけて起動すればOK。
-> too many なんちゃら、worker なんちゃら
-> nginx.confにworker_rlimit_nofileやworker_connectionsの設定があるのでいじると良い。
Nginxのパフォーマンスを極限にするための考察
https://qiita.com/iwai/items/1e29adbdd269380167d2
-> shared connection is busy while proxying connection
-> luaからgo-udp-serverへとudpデータを送付する際に、nginx luaのソケットIOの限界を超えていると発生する。
クライアント側エラー
-> connection refused, reset by peer
-> サーバのtcp/ipのソケット数限界に達している可能性が高い。
ubuntuだったら echo 1024 65535 > /proc/sys/net/ipv4/ip_local_port_range とかでパラメータ変えればいい。
disquuun部分の負荷が高い
-> disquuunの初期化パラメータのコネクション数を増やすことで負荷が下がるケースがある。
60fps 10msg/sec up/down 5000接続以上で動く場合は、30接続くらいでいい予感がする。それ以上あげてもスペックが変わらない。
また、各ユーザー向けのデータを送付時に各ユーザー単位でまとめると負荷が下がる。
理想は1fあたり各ユーザーに対して1通の送付にできるといい。
go-udp-serverの負荷が高い
-> 20~30%くらいはよくある感じ。
メッセージ送付の数にそのまま関連するので、downstreamのメッセージ数を見直すと解消しやすい。
各パーツの有無による負荷変動について
lua <-> disqueのメッセージのやり取りは、nginxのworkerへの負荷がわずかにある。
これはworker数が多ければ多いほど各workerの負荷が減るので、スペックが伸びる。
4コアのマシンで 60fps 10msg/sec up/down を実施させたところ、6000接続を超えたあたりからクライアントへのデータ送付がわずかに遅れるケースが散見された。
nginx workerに負荷がかかるとどうなるか
・クライアントへと届けるdownstreamが遅延する。ここが遅延するのは、各workerの負荷が40%とかを超えてから。worker数が多ければ避けられる。
worker数を増やす、downstreamへのメッセージをまとめる、などで負荷が軽減できる。
disqueに負荷がかかるとどうなるか
・あまり問題が起こらない。メッセージの到達遅延につながったことはないっぽい。
もし負荷が気になる場合、luaを改変してworkerごとに異なるdisqueにつながるようにするといいと思う。
upstreamに負荷がかかるとどうなるか
・全体のメッセージの流れが遅延する。
upstreamのメッセージの吸い出しが遅くなり、
downstreamのメッセージの送付が遅くなる。
メッセージの消化不良が溜まっていく以外に影響はでない。
ただし、メッセージの消化不良が起こると、disqueがどんどんメモリを食っていくことになるので、
その辺に関して注意が必要。メッセージサイズが小さい + メッセージをまとめて扱うような工夫が効果が高い。
go-udp-serverに負荷がかかるとどうなるか
・あまり問題が起こらない。メッセージの到達遅延につながったことはないっぽい。
負荷が気になる場合、新規にポートを指定してgo-udp-serverを追加し、nginx.confへとポート情報を加える。
すると自動的に複数のgo-udp-serverへとデータが流れるため、負荷が分散できる。
locust
ベンチマークはlocustを使って行なっている。
使用しているlocust fileは、https://github.com/sassembla/nginx-luajit-ws/tree/benchmark-with-netcore/locust に動作するものを置いてある。
サンプルはDockerコンテナで動作するものになっていて、
ubuntuなどに放り込んで、rebuild.shを実行すれば起動、その後ポート8089にアクセスすれば接続と負荷掛けを実行できる。
デフォルトで設定されている負荷は、
・1locustごとに1接続
・1locustにつき秒間10件程度のデータをサーバに送り、サーバから秒間10件程度のtcp + udpでのエコーを返す
・接続が確立されたり、切断されるとlocustのコンソールにメッセージやエラーが表示される
・データを受け取っていない場合のwarningとして、1秒以上データを受け取っていないクライアントにwarningが出る
変更すべき設定は、接続先のサーバのIPとport。
locusts.py(https://github.com/sassembla/nginx-luajit-ws/blob/benchmark-with-netcore/locust/test/locusts.py)
server_ip = "150.95.211.59"
# server_ip = "127.0.0.1"
server_port = 8080
message_per_sec = 100.0 / 1000.0
server_path = "sample_disque_client"
server_ip、
server_port、
message_per_sec:メッセージを秒間何件クライアントから送付 -> サーバから返送するか
この記述だと100.0 / 1000.0 = 0.1で、秒間10件送付する。名前間違えたな。。
server_path: url末尾につけられるパス。server_ip:server_port//server_path になる。